﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using SwiftMVVM.ViewModel;
using System.Windows;
using System.ComponentModel;
using System.Collections.Specialized;
using System.Reflection;
using System.Collections;

namespace SwiftMVVM.ChangeTracking
{
    /// <summary>
    /// This class monitors all the Property/Collection Change notifications
    /// on a given object graph
    /// </summary>
    internal sealed class NotifyChangeMonitor : BindingObject, IWeakEventListener
    {
        private static readonly object lockObject = new object();
        /// <summary>
        /// Fires to verify that we're interested in monitoring a property on an object
        /// </summary>
        public event EventHandler<VerifyMonitorObjectPropertyEventArgs> VerifyMonitorObjectProperty;

        /// <summary>
        /// Fires when an item is added or removed from a monitored collection
        /// </summary>
        public event EventHandler<CollectionChangeEventArgs> MonitoredCollectionChanged;

        /// <summary>
        /// Fires when a property changes on a monitored object
        /// </summary>
        public event EventHandler<PropertyChangeEventArgs> MonitoredObjectPropertyChanged;

        /// <summary>
        /// Fires after the notifications on an ObjectPropertyChange hanve already been sent
        /// </summary>
        public event EventHandler<PropertyChangeEventArgs> MonitoredObjectPropertyChangedNotificationsSent;

        /// <summary>
        /// For performance reasons we want to store properties explicitly not tracked by type
        /// </summary>
        private static readonly Dictionary<Type, HashSet<string>> typeNotifyProperties =
            new Dictionary<Type, HashSet<string>>();

        /// <summary>
        /// We can only send property notifications for publicly accesible properties
        /// </summary>
        private static readonly Dictionary<Type, HashSet<string>> typePublicProperties =
            new Dictionary<Type, HashSet<string>>();

        /// <summary>
        /// Stores a list of monitored properties by object, we need this in case a user somehow manages to null
        /// out an object 
        /// </summary>
        private readonly Dictionary<INotifyPropertyChanged, HashSet<INotifyPropertyChanged>> monitoredObjects = new Dictionary<INotifyPropertyChanged, HashSet<INotifyPropertyChanged>>();

        /// <summary>
        /// When we begin monitoring an object that is a property on another object it's nice to know it's property name
        /// for auditing. This is not necessary and we will not require it. In the case that we do not have it
        /// we'll just diplay the type of the object instead.
        /// </summary>
        private readonly Dictionary<INotifyPropertyChanged, string> monitoredObjectNames = new Dictionary<INotifyPropertyChanged, string>();

        /// <summary>
        /// When we get a NotifyPropertyChanging we store the value here, and remove it when the corresponingChanged fires.
        /// Then we pass it to the consumer in one shot so they can examine the old and new values in MonitoredObjectPropertyChanged
        /// </summary>
        private readonly Dictionary<INotifyPropertyChanged, Dictionary<string, object>> monitoredObjectPriorValues = new Dictionary<INotifyPropertyChanged, Dictionary<string, object>>();

        /// <summary>
        /// The count property is firing propertyChanged (on ObservableCollection before the collection notifies us
        /// of the newly added/deleted items. So we suppress it by holding it in here, then fire the count
        /// after we process the change in the collection
        /// </summary>
        private readonly Dictionary<INotifyCollectionChanged, PropertyChangeEventArgs>
            collectionCountPropertyChangeQueue =
           new Dictionary<INotifyCollectionChanged, PropertyChangeEventArgs>();

        /// <summary>
        /// Called when we receive a PropertyChanged on an object.
        /// If the object also supports INotifyProperyChanging we will pass
        /// the prior and current values here.
        /// </summary>
        /// <param name="sourceObject">The source object that implements INotifyPropertyChanged</param>
        /// <param name="propertyName">The name of the property that changed</param>
        /// <param name="oldValue">The old value (only supported if the object also implements INotifyPropertyChanging</param>
        /// <param name="newValue">The current value</param>
        private void OnMonitoredObjectPropertyChanged(INotifyPropertyChanged sourceObject, string propertyName, object oldValue, object newValue)
        {
            var handler = this.MonitoredObjectPropertyChanged;
            PropertyChangeEventArgs args = null;
            if (handler != null)
            {
                args = new PropertyChangeEventArgs(sourceObject, propertyName, oldValue, newValue);
                var sourceNotifyCollectionChanged = sourceObject as INotifyCollectionChanged;
                if (sourceNotifyCollectionChanged != null && propertyName == "Count")
                {
                    //We need to defer count notifications until after INotifyCollectionChanged
                    //has processed
                    //1/10/2013
                    //Some collections will fire multiple Count changes prior to INotifyCollectionChanged
                    //(ie. BulkObservableCollection)
                    PropertyChangeEventArgs tempArgs;
                    if (!collectionCountPropertyChangeQueue.TryGetValue(sourceNotifyCollectionChanged, out tempArgs))
                    {
                        collectionCountPropertyChangeQueue.Add(sourceNotifyCollectionChanged, args);
                    }
                    return;
                }
                handler(this, args);
            }
        }

        /// <summary>
        /// Fires after PropertyNotifications have been sent
        /// </summary>
        /// <param name="sourceObject">The source object that implements INotifyPropertyChanged</param>
        /// <param name="propertyName">The name of the property that changed</param>
        /// <param name="oldValue">The old value (only supported if the object also implements INotifyPropertyChanging</param>
        /// <param name="newValue">The current value</param>
        private void OnMonitoredObjectPropertyChangedNotificationsSent(DependentBindingObject sourceObject, string propertyName, object oldValue, object newValue)
        {
            var handler = this.MonitoredObjectPropertyChangedNotificationsSent;
            if (handler != null)
            {
                handler(this, new PropertyChangeEventArgs((INotifyPropertyChanged)sourceObject, propertyName, oldValue, newValue));
            }
        }


        /// <summary>
        /// Fires when a monitored collection changes
        /// </summary>
        /// <param name="sourceObject">The collection that changed</param>
        /// <param name="e">The original event args containing the items affected by the change</param>
        private void OnMonitoredCollectionChanged(INotifyCollectionChanged sourceObject, NotifyCollectionChangedEventArgs e)
        {
            var handler = this.MonitoredCollectionChanged;
            if (handler != null)
            {
                handler(this, new CollectionChangeEventArgs(sourceObject, e));
            }

            PropertyChangeEventArgs countPropertyChangedArgs;
            if (collectionCountPropertyChangeQueue.TryGetValue(sourceObject, out countPropertyChangedArgs))
            {
                collectionCountPropertyChangeQueue.Remove(sourceObject);
                var propertyChangedHandler = this.MonitoredObjectPropertyChanged;
                if (propertyChangedHandler != null)
                {
                    propertyChangedHandler(this, countPropertyChangedArgs);
                }
            }
        }

        /// <summary>
        /// This is used to verify that you want to delve further into the object graph.
        /// By default the Monitor will traverse the entire graph hooking into any object
        /// that implements INotifyPropertyChanged/INotifyCollectionChanged. Here
        /// you will be called prior to connection to confirm. 
        /// </summary>
        /// <param name="sourceObject">Source Object</param>
        /// <param name="propertyInfo">Information about the property we're trying to connect to</param>
        /// <param name="propertyValue">The current value of the property</param>
        private bool OnVerifyMonitorObjectProperty(object sourceObject, PropertyInfo propertyInfo)
        {
            var handler = this.VerifyMonitorObjectProperty;
            if (handler != null)
            {
                var args = new VerifyMonitorObjectPropertyEventArgs(sourceObject, propertyInfo);
                handler(this, args);
                return !args.Cancel;
            }
            return true;
        }


        /// <summary>
        /// We allow you to optionally specify a name for a given object.
        /// We default to the property name when traversing sub objects.
        /// If a name however does not exist for a given object, we return the type name
        /// </summary>
        /// <param name="value">The value we're looking up</param>
        /// <returns>The name assigned, or the type name if no name was assigned</returns>
        public string GetMonitoredObjectName(INotifyPropertyChanged value)
        {
            string monitoredObjectName;
            if (!this.monitoredObjectNames.TryGetValue(value, out monitoredObjectName))
            {
                //If we done have a name, return the object type name
                monitoredObjectName = value.GetType().Name;
            }

            return monitoredObjectName;
        }

        /// <summary>
        /// Returns a list of currently monitored objects
        /// </summary>
        public IEnumerable<INotifyPropertyChanged> MonitoredObjects
        {
            get { return monitoredObjects.Select(a => { return a.Key; }); }
        }



        /// <summary>
        /// Reflects into and retrieves the property value
        /// </summary>
        /// <param name="source">The object we're getting the property value for</param>
        /// <param name="property">The name of the property</param>
        /// <returns>The value of the property</returns>
        private static object GetPropertyValue(object source, string property)
        {
            var collectionSource = source as INotifyCollectionChanged;
            if (collectionSource != null &&
                property.EndsWith("[]"))
            {
                //Probably the indexer
                property = property.Remove(property.Length - 2, 2);
            }
            var propertyInfo = source.GetType().GetProperty(property);
            if (propertyInfo.GetIndexParameters().Length == 0)
            {
                return propertyInfo.GetValue(source, null);
            }
            return null;
        }



        /// <summary>
        /// Connects the object passed in as value to change tracking
        /// This will recurse thru all properties on this object that support INotifyPropertyChanged
        /// If the object exposes properties implementing INotifyCollectionChanged, then they will 
        /// be iterated thru as well
        /// </summary>
        /// <param name="value">The object we want to monitor</param>
        /// <param name="name">optional name for the object (defaults to property name)</param>
        public void StartMonitor(INotifyPropertyChanged value, string name = "")
        {
            if (value == null) { throw new ArgumentNullException("value"); }
            if (monitoredObjects.ContainsKey(value)) { return; }
            monitoredObjects.Add(value, null);


            if (!string.IsNullOrWhiteSpace(name))
            {
                //Store the name of the object for auditing and undo/redo lists (optional)
                monitoredObjectNames.Add(value, name);
            }

            PropertyChangedEventManager.AddListener(value, this, "");

            //INotifyPropertyChanging is optional, but it allows for much more detailed
            //PropertyChange notifications by easily allowing us to pass you the prior value.
            //It is strongly recomended that you also implement this interface
            var notifyPropertyChanging = value as INotifyPropertyChanging;
            if (notifyPropertyChanging != null)
            {
                PropertyChangingEventManager.AddListener(notifyPropertyChanging, this);
            }

            var notifyCollectionChanged = value as INotifyCollectionChanged;
            if (notifyCollectionChanged != null)
            {
                CollectionChangedEventManager.AddListener(notifyCollectionChanged, this);
            }

            var notifyDependentPropertyNotificationsSent = value as DependentBindingObject;
            if (notifyDependentPropertyNotificationsSent != null)
            {
                PropertyNotificationsSentEventManager.AddListener(notifyDependentPropertyNotificationsSent, this);
            }


            //We support iterating thru properties that support INotifyCollectionChanged
            //or INotifyPropertyChanged. We want to cache this off by type so we dont have to 
            //do this over and over, especially for collections of objects
            var publicProperties = typePublicProperties.GetOrAdd(lockObject, value.GetType(), key =>
            {
                return new HashSet<string>(from propertyInfo in key.GetProperties() select propertyInfo.Name);
            });

            var notifyProperties = typeNotifyProperties.GetOrAdd(lockObject, value.GetType(), key =>
            {
                return new HashSet<string>(key.GetProperties()
                    .Where(propertyInfo =>
                        typeof(INotifyCollectionChanged).IsAssignableFrom(propertyInfo.PropertyType) ||
                        typeof(INotifyPropertyChanged).IsAssignableFrom(propertyInfo.PropertyType))
                        .Select(propertyInfo => propertyInfo.Name));
            });


            foreach (var propertyInfo in publicProperties
                .Select(publicProperty => value.GetType().GetProperty(publicProperty))
                .Where(propertyInfo => 
                    !propertyInfo.CanWrite &&
                    (propertyInfo.PropertyType.IsValueType ||
                    propertyInfo.PropertyType.FullName == "System.String")))
            {
             
                //Check this Property to see if it is a ReadOnly Value Type.
                //We need to know this because this property can be Refreshed
                //via an external PropertyChanged call and we'll need to track it's 
                //prior value
                Dictionary<string, object> priorValues;
                if (!monitoredObjectPriorValues.TryGetValue(value, out priorValues))
                {
                    monitoredObjectPriorValues.Add(value, priorValues = new Dictionary<string, object>());
                }
                priorValues.Add(propertyInfo.Name, GetPropertyValue(value, propertyInfo.Name));
            }

            //Examine each property that implements 
            //INotifyPropertyChanged or INotifyPropertyChanging on this
            //object. 
            foreach (var propertyInfo in notifyProperties
                .Select(notifyProperty => value.GetType().GetProperty(notifyProperty))
                .Where(propertyInfo => propertyInfo.GetIndexParameters().Length == 0
                    && OnVerifyMonitorObjectProperty(value, propertyInfo)))
            {
                //We dont want to examine indexers on collections
                //Querying the property value causes issues with lazy loaded 
                //properties. So we only want to get the actual value
                //if the user indicates they want to track it.

                //See if we're interested in tracking this property
                var propertyValue = propertyInfo.GetValue(value, null);
                var notifyPropertyChangedPropertyValue = (INotifyPropertyChanged)propertyValue;
                //This may be a 'parent' object if so we do not want to monitor that

                if (propertyValue != null && !monitoredObjects.ContainsKey(notifyPropertyChangedPropertyValue))
                {
                    //Mark this property as a INotifyChanged property being owned by value

                    if (monitoredObjects[value] == null) { monitoredObjects[value] = new HashSet<INotifyPropertyChanged>(); }

                    monitoredObjects[value].Add(notifyPropertyChangedPropertyValue);

                    //Actually start monitoring this property
                    StartMonitor(notifyPropertyChangedPropertyValue, propertyInfo.Name);
                }
            }

            //If we're a collection we want to monitor the sub items in the collection
            //if and only if it supports INotifyCollectionChanged
            var collection = value as ICollection;
            if (collection != null && collection is INotifyCollectionChanged)
            {
                foreach (var notifyChangedItem in collection.OfType<INotifyPropertyChanged>())
                {
                    StartMonitor(notifyChangedItem);
                }
            }
        }



        /// <summary>
        /// Disconnects the object passed in as Value from change tracking
        /// </summary>
        /// <param name="value">The object we want to monitor. This will also 
        /// disconnect any objects underneath this object in the object graph</param>
        public void StopMonitor(INotifyPropertyChanged value)
        {
            if (value == null) { throw new ArgumentNullException("value"); }
            if (!monitoredObjects.ContainsKey(value)) { throw new InvalidOperationException("Cannot stop monitoring on an object that is not monitored"); }

            //If we're a collection stop monitoring the sub items in the collection
            //if and only if it supports INotifyCollectionChanged
            var collection = value as ICollection;
            if (collection != null && collection is INotifyCollectionChanged)
            {
                foreach (var notifyChangedItem in collection.OfType<INotifyPropertyChanged>())
                {
                    StopMonitor(notifyChangedItem);
                }
            }

            PropertyChangedEventManager.RemoveListener(value, this, "");

            var notifyPropertyChanging = value as INotifyPropertyChanging;
            if (notifyPropertyChanging != null)
            {
                PropertyChangingEventManager.RemoveListener(notifyPropertyChanging, this);
            }

            var notifyCollectionChanged = value as INotifyCollectionChanged;
            if (notifyCollectionChanged != null)
            {
                CollectionChangedEventManager.RemoveListener(notifyCollectionChanged, this);
            }

            var notifyDependentPropertyNotificationsSent = value as DependentBindingObject;
            if (notifyDependentPropertyNotificationsSent != null)
            {
                PropertyNotificationsSentEventManager.RemoveListener(notifyDependentPropertyNotificationsSent, this);
            }

            //Disconnect from the properties that we're listening to.
            //We stored this information from the initial corresponding StartMonitor
            if (monitoredObjects[value] != null)
            {
                foreach (var monitoredSubObject in monitoredObjects[value])
                {
                    StopMonitor(monitoredSubObject);
                }
                monitoredObjects[value].Clear();
                monitoredObjects[value] = null;
            }

            Dictionary<string, object> priorValues;
            if (monitoredObjectPriorValues.TryGetValue(value, out priorValues))
            {
                priorValues.Clear();
            }

            monitoredObjectNames.Remove(value);

            monitoredObjects.Remove(value);
        }

        /// <summary>
        /// We really like, but dont require, objects to implement INotifyPropertyChanging.
        /// What that allows us to do is temporarily store the current (prior) value before the change
        /// to the new value occurs, so that when we notify the consumer of the monitor we can give them
        /// both the old and new value
        /// </summary>
        /// <param name="sender">The change source</param>
        /// <param name="e">The property that changed</param>
        private void trackChangesObject_PropertyChanging(object sender, PropertyChangingEventArgs e)
        {
            var publicProperties = typePublicProperties[sender.GetType()];
            if (publicProperties != null && !publicProperties.Contains(e.PropertyName))
            {
                //We do not want to propogate change notifications for private properties
                return;
            }

            var notifyPropertyChangedObject = (INotifyPropertyChanged)sender;
            Dictionary<string, object> priorValues;
            if (!monitoredObjectPriorValues.TryGetValue(notifyPropertyChangedObject, out priorValues))
            {
                monitoredObjectPriorValues.Add(notifyPropertyChangedObject, priorValues = new Dictionary<string, object>());
            }

            //We'll track readonly variables in the property changed handler
            var propertyInfo = notifyPropertyChangedObject.GetType().GetProperty(e.PropertyName);
            if (propertyInfo.CanWrite)
            {
                var currentValue = GetPropertyValue(sender, e.PropertyName);
                priorValues.Add(e.PropertyName, currentValue);
            }
        }

        private void notifyDependentObject_PropertyNotificationsSent(object sender, PropertyChangedEventArgs e)
        {
            var publicProperties = typePublicProperties[sender.GetType()];
            if (publicProperties != null && !publicProperties.Contains(e.PropertyName))
            {
                //We do not want to propogate change notifications for private properties
                return;
            }
            OnMonitoredObjectPropertyChangedNotificationsSent((DependentBindingObject)sender, e.PropertyName, null, null);
        }

        /// <summary>
        /// We fire this off when the source object fires off PropertyChanged.
        /// </summary>
        /// <param name="sender">The change source</param>
        /// <param name="e">The property that changed</param>
        private void trackChangesObject_PropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            var publicProperties = typePublicProperties[sender.GetType()];
            if (publicProperties != null && !publicProperties.Contains(e.PropertyName))
            {
                //We do not want to propogate change notifications for private properties
                return;
            }

            var notifyPropertyChangedObject = (INotifyPropertyChanged)sender;
            var notifyPropertyChangingdObject = sender as INotifyPropertyChanging;
            var newValue = GetPropertyValue(sender, e.PropertyName);
            if (notifyPropertyChangingdObject == null)
            {
                //This object does not support INotifyPropertyChanging so we do not have an original value.
                //We have to pass something here for the old value. 
                //For now I am passing off this string to coerce the user to think
                //about the benefits of implementing INotifyPropertyChanging
                OnMonitoredObjectPropertyChanged(notifyPropertyChangedObject, e.PropertyName,
                    "Object does not support INotifyPropertyChanging", newValue);

                //Also we cannot automatically connect to the object if it supports 
                //INotifyPropertyChanged because we'd have no way to disconnect from the old 
                //value here.
                if (newValue is INotifyPropertyChanged)
                {
                    throw new InvalidOperationException(string.Format("Cannot automatically connect to INotifyPropertyChanged " +
                    "for this object because {0} does not support INotifyPropertyChanging. Either Implement" +
                    "INotifyPropertyChanging, or filter this property out of tracking using OnVerifyMonitorObjectProperty.",
                    sender.GetType().Name));
                }

            }
            else
            {
                //Get the old value that we stored in INotifyPropertyChanging and remove it from the store
                object oldValue = null;
                if (monitoredObjectPriorValues[notifyPropertyChangedObject].TryGetValue(e.PropertyName, out oldValue))
                {
                    monitoredObjectPriorValues[notifyPropertyChangedObject].Remove(e.PropertyName);
                }


                //If this is a ReadOnly property, we will never get a PropertyChanging for it.
                //Take the Current value of the property and store it as the new 'old' value.
                var propertyInfo = notifyPropertyChangedObject.GetType().GetProperty(e.PropertyName);
                if (propertyInfo == null) { throw new InvalidOperationException("Invalid Property Name"); }
                if (!propertyInfo.CanWrite)
                {
                    if (propertyInfo.PropertyType.IsValueType ||
                    propertyInfo.PropertyType.FullName == "System.String")
                    {
                        monitoredObjectPriorValues[notifyPropertyChangedObject].Add(e.PropertyName,
                            GetPropertyValue(notifyPropertyChangedObject, e.PropertyName));
                    }
                    else
                    {
                        //We're getting in here because we have
                        //a readonly reference type.
                        //Because it's readonly we're also
                        //not going to have a prior value for it 
                        //so no need to go thru disconnection logic
                        //Notify the client of the change with both the old and new value
                        OnMonitoredObjectPropertyChanged(notifyPropertyChangedObject, e.PropertyName, oldValue, newValue);
                        return;
                    }
                }

                var oldValueAsNotifyPropertyChanged = oldValue as INotifyPropertyChanged;
                if (oldValueAsNotifyPropertyChanged != null 
                    && monitoredObjects.ContainsKey(oldValueAsNotifyPropertyChanged))
                {
                    StopMonitor(oldValueAsNotifyPropertyChanged);
                }

                //Kill the dictionary if we're not tracking any other property values
                if (monitoredObjectPriorValues[notifyPropertyChangedObject].Count == 0)
                {
                    monitoredObjectPriorValues.Remove(notifyPropertyChangedObject);
                }


                var newValueAsNotifyPropertyChanged = newValue as INotifyPropertyChanged;
                if (newValueAsNotifyPropertyChanged != null &&
                    OnVerifyMonitorObjectProperty(sender, propertyInfo))
                {
                    StartMonitor(newValueAsNotifyPropertyChanged, e.PropertyName);
                }

                //Notify the client of the change with both the old and new value
                OnMonitoredObjectPropertyChanged(notifyPropertyChangedObject, e.PropertyName, oldValue, newValue);
            }
        }

        /// <summary>
        /// Fired when a collection changes
        /// </summary>
        /// <param name="sender">The source of the collection change</param>
        /// <param name="e">Information about the collection change</param>
        private void notifyCollectionChanged_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            //When items are added to the collection automatically begin monitoring them.
            //When items are removed, or if the collection is reset, stop all monitoring on the objects
            //in the collection
            var notifyCollectionChanged = (INotifyCollectionChanged)sender;
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Reset:
                    foreach (INotifyPropertyChanged item in ((ICollection)sender))
                    {
                        //Certain collections like BulkObservableCollection fire only a Reset
                        //after an AddRange function, so 
                        if (monitoredObjects.ContainsKey(item))
                        {
                            StopMonitor(item);
                        }
                        StartMonitor(item);
                    }
                    break;
                case NotifyCollectionChangedAction.Remove:
                    foreach (var item in e.OldItems.Cast<INotifyPropertyChanged>())
                    {
                        StopMonitor(item);
                    }
                    break;
                case NotifyCollectionChangedAction.Add:
                    foreach (var item in e.NewItems)
                    {
                        var notifyPropertyChanged = item as INotifyPropertyChanged;
                        if (notifyPropertyChanged != null)
                        {
                            StartMonitor(notifyPropertyChanged);
                        }
                    }
                    break;
            }
            OnMonitoredCollectionChanged(notifyCollectionChanged, e);
        }

        public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
        {
            if (managerType == typeof(PropertyChangedEventManager))
            {
                trackChangesObject_PropertyChanged(sender, (PropertyChangedEventArgs)e);
                return true;
            }
            else if (managerType == typeof(CollectionChangedEventManager))
            {
                notifyCollectionChanged_CollectionChanged(sender, (NotifyCollectionChangedEventArgs)e);
                return true;
            }
            else if (managerType == typeof(PropertyChangingEventManager))
            {
                trackChangesObject_PropertyChanging(sender, (PropertyChangingEventArgs)e);
                return true;
            }
            else if (managerType == typeof(PropertyNotificationsSentEventManager))
            {
                notifyDependentObject_PropertyNotificationsSent(sender, (PropertyChangedEventArgs)e);
                return true;
            }
            return false;
        }
    }
}
